Skip to content

fix(catalog): preserve virtual result rows#172

Merged
ian-pascoe merged 1 commit into
mainfrom
codex/fix-catalog-icon-flicker
Jun 28, 2026
Merged

fix(catalog): preserve virtual result rows#172
ian-pascoe merged 1 commit into
mainfrom
codex/fix-catalog-icon-flicker

Conversation

@ian-pascoe

@ian-pascoe ian-pascoe commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Catalog icons should no longer flicker while scrolling the production search results. The virtual list now keeps row DOM nodes alive when they remain in the visible window instead of replacing the entire row group on every scroll update.

Validation

  • pnpm --filter @caplets/catalog test -- test/virtual-results.test.ts test/search-row.test.ts
  • pnpm --filter @caplets/catalog typecheck
  • pnpm --filter @caplets/catalog build
  • pnpm format:check
  • pre-push pnpm verify

Compound Engineering
Codex

Summary by CodeRabbit

  • Bug Fixes
    • Virtual catalog search now reuses existing result rows while scrolling, reducing unnecessary re-renders and keeping the visible list more stable.
    • Row position and accessibility attributes are updated correctly as items move within the virtual window.
  • Tests
    • Added coverage to confirm a rendered row element is preserved during scrolling.

@coderabbitai

coderabbitai Bot commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 8637a6c9-b324-4df4-8714-b335a905b248

📥 Commits

Reviewing files that changed from the base of the PR and between 988edbb and be6fde1.

📒 Files selected for processing (2)
  • apps/catalog/src/scripts/virtual-results.ts
  • apps/catalog/test/virtual-results.test.ts

📝 Walkthrough

Walkthrough

initVirtualCatalogSearch gains a persistent renderedRows Map keyed by virtualRowKey(). renderVirtualRows() is refactored from full list replacement to incremental keyed DOM reuse: existing elements are repositioned, new ones inserted in order, and stale ones removed. A updateRowPosition() helper centralizes attribute and transform assignment. A new test verifies node identity is preserved across a scroll.

Virtual row DOM reuse

Layer / File(s) Summary
virtualRowKey and updateRowPosition helpers
apps/catalog/src/scripts/virtual-results.ts
Adds virtualRowKey() to derive a stable key from row.id or item.key, and updateRowPosition() to set data-virtualKey, data-detailHref, data-index, aria-rowindex, and translateY. renderRow() is updated to call updateRowPosition() instead of inline setup. renderedRows Map is declared on initVirtualCatalogSearch.
Incremental renderVirtualRows and scroll reuse test
apps/catalog/src/scripts/virtual-results.ts, apps/catalog/test/virtual-results.test.ts
renderVirtualRows() now collects the next key set, reuses cached elements by repositioning them, cursor-inserts or moves elements into correct DOM order, and removes/evicts keys no longer in the visible window. The new test mounts 200 rows, scrolls to top: 72, and asserts the original first row element is still present in resultRows().

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐇 Hop, hop, the rows stay put,
No tossing nodes beneath each foot.
A Map keeps keys, the DOM stands still,
We scroll on by with virtual skill.
Less thrash, more cache — the rabbit's will! 🗝️

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: preserving virtual catalog result rows during scrolling.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/fix-catalog-icon-flicker

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

@ian-pascoe ian-pascoe merged commit a16395c into main Jun 28, 2026
7 checks passed
@ian-pascoe ian-pascoe deleted the codex/fix-catalog-icon-flicker branch June 28, 2026 13:13
@greptile-apps

greptile-apps Bot commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR replaces the replaceChildren approach in renderVirtualRows with a keyed DOM-diffing strategy backed by a renderedRows Map, preserving existing DOM nodes for rows that remain in the virtual window during scroll to prevent icon flickering caused by lazy-loaded images being reset on every scroll tick.

  • virtual-results.ts: Adds a renderedRows: Map<string, HTMLElement> cache, a cursor-based insertion loop, and a cleanup pass that removes off-screen rows from both the DOM and the map; refactors position attributes into a shared updateRowPosition helper and a virtualRowKey function.
  • virtual-results.test.ts: Adds a test that captures the first row's DOM node, scrolls 72 px, and asserts the same reference is still present in the result list.

Confidence Score: 3/5

Safe to merge for the common case where all catalog rows carry an id, but carries a real content-correctness risk if any row lacks one.

The DOM-diffing approach is well-structured and the cursor logic is correct. However, the virtualRowKey fallback to String(item.key) (i.e., the row's index) means that after a filter is applied, a different row at the same index slot inherits the old row's DOM element — including its innerHTML. updateRowPosition only patches transform and aria attributes, so the wrong name, description, and icon would be shown. This is a present defect whenever row.id is absent, and the explicit index-based fallback in both getItemKey and virtualRowKey implies the type allows it. The renderedRows map is also never cleared in destroy(), which is a minor teardown gap.

apps/catalog/src/scripts/virtual-results.ts — specifically the virtualRowKey fallback path and the destroy method.

Important Files Changed

Filename Overview
apps/catalog/src/scripts/virtual-results.ts Introduces keyed DOM-diffing via renderedRows Map and cursor-based insertBefore loop. Core scroll-reuse logic is sound for rows with IDs, but the index-string fallback key (String(item.key)) means a filter change can map a new row to a cached DOM element that contains a different row's HTML content — updateRowPosition only patches position attributes, not innerHTML.
apps/catalog/test/virtual-results.test.ts Adds a node-reuse test that correctly validates the new DOM-preservation behaviour. The test only scrolls one row-height (72 px), staying within the overscan window, which is appropriate. No coverage for the filter-then-verify-content path on ID-less rows.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[scroll / filter event] --> B[renderVirtualRows]
    B --> C[virtualizer.getVirtualItems]
    C --> D{for each virtual item}
    D --> E[virtualRowKey: row.id ?? String item.key]
    E --> F{renderedRows.has key?}
    F -- No --> G[renderRow: createElement + innerHTML]
    G --> H[renderedRows.set key, element]
    F -- Yes --> I[updateRowPosition: transform + aria attrs]
    H --> J{element !== cursor?}
    I --> J
    J -- Yes --> K[insertBefore element, cursor]
    J -- No --> L[advance cursor]
    K --> L
    L --> D
    D -- done --> M[cleanup pass: querySelectorAll data-result-row]
    M --> N{key in nextKeys?}
    N -- Yes --> O[keep element in DOM + map]
    N -- No --> P[element.remove + renderedRows.delete key]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[scroll / filter event] --> B[renderVirtualRows]
    B --> C[virtualizer.getVirtualItems]
    C --> D{for each virtual item}
    D --> E[virtualRowKey: row.id ?? String item.key]
    E --> F{renderedRows.has key?}
    F -- No --> G[renderRow: createElement + innerHTML]
    G --> H[renderedRows.set key, element]
    F -- Yes --> I[updateRowPosition: transform + aria attrs]
    H --> J{element !== cursor?}
    I --> J
    J -- Yes --> K[insertBefore element, cursor]
    J -- No --> L[advance cursor]
    K --> L
    L --> D
    D -- done --> M[cleanup pass: querySelectorAll data-result-row]
    M --> N{key in nextKeys?}
    N -- Yes --> O[keep element in DOM + map]
    N -- No --> P[element.remove + renderedRows.delete key]
Loading

Comments Outside Diff (1)

  1. apps/catalog/src/scripts/virtual-results.ts, line 236-239 (link)

    P2 Explicitly clearing renderedRows on destroy ensures detached DOM elements are released immediately rather than waiting for the closure to be GC'd.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

    Fix in Codex

Fix All in Codex

Reviews (1): Last reviewed commit: "fix(catalog): preserve virtual result ro..." | Re-trigger Greptile

Comment on lines +320 to +322
function virtualRowKey(item: VirtualItem, row: CatalogSearchRow | undefined): string {
return row?.id ?? String(item.key);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Index-based key collides across filter changes for ID-less rows

virtualRowKey falls back to String(item.key) when row.id is absent. item.key is the virtualizer's output of getItemKey, which is itself visibleRows[index]?.id ?? index — so for an ID-less row the key reduces to String(index). After applySearch replaces visibleRows, index N in the new filtered set may point to a completely different row than index N in the old set. renderedRows.get("N") returns the old element, and because updateRowPosition only patches transform/aria attributes (not innerHTML), the row is displayed with the wrong name, description, and icon. The cleanup pass does not evict it because "N" is still present in nextKeys. If CatalogSearchRow.id is always defined in production data this is dormant, but the explicit fallback in both getItemKey and virtualRowKey signals the type permits absent IDs. Clearing renderedRows (or at minimum evicting non-ID entries) at the start of applySearch before calling renderVirtualRows would close the gap.

Fix in Codex

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant